You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

629 lines
19 KiB

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import {
hasQuestionAnswerValue,
QuestionAnswersProvider,
useQuestionAnswers,
} from "@/components/questions/question-answer-storage";
import QuestionExitNavigationButton from "@/components/questions/question-exit-navigation-button";
import QuestionRenderer from "@/components/questions/question-renderer";
import QuestionSectionFlow from "@/components/questions/question-section-flow";
import TestIntroPage from "@/components/questions/test-intro-page";
import TestQuestionsFlow, {
type TestQuestion,
} from "@/components/questions/test-questions-flow";
import { DotsLoader } from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background";
import { cattellFallbackQuestions } from "@/data/cattell-fallback";
import { glasserFallbackQuestions } from "@/data/glasser-fallback";
import {
getQuestionListItemBySlug,
isQuestionListItemVisibleForProfile,
isQuestionRequiredForProfile,
isQuestionVisibleForProfile,
type QuestionField,
} from "@/data/question-data";
import type { MarriageGender } from "@/hooks/marriage/types";
import {
useCattellQuestionsQuery,
useSubmitCattellAssessmentMutation,
} from "@/hooks/marriage/use-cattell";
import {
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import AnswerPaceSheet from "./answer-pace-sheet";
type QuestionDetailClientProps = {
closeLabel: string;
continueLabel: string;
description: string;
informationLabel: string;
itemSlug: string;
locale?: Locale;
questionsListHref: string;
title: string;
};
type StoredQuestionField = {
label?: string;
value?: unknown;
type?: string;
key?: string;
};
type StoredAnswers = {
fields?: StoredQuestionField[];
};
function getQuestionStorageKey(slug: string) {
return `marriage:sections:${slug}:answers`;
}
function parseStoredAge(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
const numericAge = Number(trimmedValue);
if (Number.isFinite(numericAge)) {
return numericAge;
}
const dateOfBirth = new Date(trimmedValue);
if (Number.isNaN(dateOfBirth.getTime())) {
return null;
}
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const hasBirthdayPassed =
today.getMonth() > dateOfBirth.getMonth() ||
(today.getMonth() === dateOfBirth.getMonth() &&
today.getDate() >= dateOfBirth.getDate());
if (!hasBirthdayPassed) {
age -= 1;
}
return age >= 0 ? age : null;
}
return null;
}
function getStoredAge() {
try {
const rawValue = window.localStorage.getItem(
getQuestionStorageKey("personal_info"),
);
if (!rawValue) {
return null;
}
const storedAnswers = JSON.parse(rawValue) as StoredAnswers;
const ageField = storedAnswers.fields?.find(
(field) =>
field.type === "number" ||
field.label === "Age" ||
field.label === "سن" ||
(typeof (field as any).key === "string" &&
((field as any).key.endsWith("_age") ||
(field as any).key.endsWith("_sn"))),
);
if (ageField) {
return parseStoredAge(ageField.value);
}
const dateOfBirthField = storedAnswers.fields?.find(
(field) =>
field.type === "date" ||
field.label === "Date of Birth" ||
field.label === "تاریخ تولد" ||
(typeof (field as any).key === "string" &&
((field as any).key.endsWith("_date_of_birth") ||
(field as any).key.endsWith("_tarykh_twld"))),
);
return parseStoredAge(dateOfBirthField?.value);
} catch {
return null;
}
}
function QuestionFlowWrapper({
visibleQuestions,
itemSlug,
dobQuestion,
dobQuestionIndex,
continueLabel,
questionsListHref,
}: {
visibleQuestions: QuestionField[];
itemSlug: string;
dobQuestion?: QuestionField;
dobQuestionIndex?: number;
requiredQuestionsCount: number;
continueLabel: string;
questionsListHref: string;
}) {
const { getAnswerValue } = useQuestionAnswers();
const dynamicQuestions = useMemo(() => {
return visibleQuestions.filter((question) => {
if (question.logic?.dependsOn) {
const { title, values } = question.logic.dependsOn;
const dependentQuestionIndex = visibleQuestions.findIndex(
(q) => q.title === title,
);
if (dependentQuestionIndex !== -1) {
const dependentQuestion = visibleQuestions[dependentQuestionIndex];
const answer = getAnswerValue(
dependentQuestion,
dependentQuestionIndex,
);
return values.includes(String(answer));
}
return false;
}
return true;
});
}, [visibleQuestions, getAnswerValue]);
const requiredCount = useMemo(
() => dynamicQuestions.filter((q) => q.required).length,
[dynamicQuestions],
);
return (
<QuestionSectionFlow
key={itemSlug}
total={requiredCount}
continueLabel={continueLabel}
exitHref={questionsListHref}
optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) =>
question.required ? [] : [index],
)}
questions={dynamicQuestions}
>
{dynamicQuestions.map((question, index) => {
const originalIndex = visibleQuestions.indexOf(question);
const answer = getAnswerValue(question, originalIndex);
const hasAnswer = hasQuestionAnswerValue(answer ?? null);
let isAnswered = hasAnswer;
if (hasAnswer) {
const isEmailQuestion =
question.title.toLowerCase().includes("email") ||
question.title.includes("ایمیل");
if (isEmailQuestion) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
isAnswered = emailRegex.test(String(answer).trim());
} else if (question.type === "birthplace") {
const strVal = String(answer);
const parts = strVal.split(",").map((p) => p.trim());
isAnswered =
parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0;
}
}
return (
<div
key={`${itemSlug}-${question.title}`}
data-question-required={String(question.required)}
data-question-optional={String(!question.required)}
data-question-index={index}
data-question-original-index={originalIndex}
data-question-disabled="false"
data-question-answered={String(isAnswered)}
>
<QuestionRenderer
question={question}
questionIndex={originalIndex}
dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
/>
</div>
);
})}
</QuestionSectionFlow>
);
}
export default function QuestionDetailClient({
closeLabel,
continueLabel,
description,
informationLabel,
itemSlug,
locale = defaultLocale,
questionsListHref,
title,
}: QuestionDetailClientProps) {
const router = useRouter();
const { dictionary: t } = useI18n();
const [isTestStarted, setIsTestStarted] = useState(false);
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery();
const profileGender = profile?.gender;
const age = getStoredAge();
const item = getQuestionListItemBySlug(itemSlug, locale);
const isCattellSlug = itemSlug === "personality_test";
const isGlasserSlug = itemSlug === "glasser_5_needs_test";
const cattellQuery = useCattellQuestionsQuery(locale, {
enabled: isCattellSlug && isTestStarted,
retry: 0,
});
const submitCattellMutation = useSubmitCattellAssessmentMutation();
const glasserQuery = useGlasserQuestionsQuery(locale, {
enabled: isGlasserSlug && isTestStarted,
retry: 0,
});
const submitGlasserMutation = useSubmitGlasserAssessmentMutation();
const profileContext = useMemo(
() => ({
age,
gender: profileGender as MarriageGender | null | undefined,
}),
[age, profileGender],
);
const cattellTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList =
cattellQuery.data?.questions && cattellQuery.data.questions.length > 0
? cattellQuery.data.questions
: cattellFallbackQuestions;
return questionsList.map((q) => {
const rawOptions =
q.options && q.options.length > 0
? q.options
: locale === "fa"
? ["بله", "به اندازه کافی واضح نیست", "نه"]
: ["Yes", "Not clear enough", "No"];
const mappedOptions = rawOptions.map((optText, idx) => ({
label: optText,
value: idx === 0 ? "A" : idx === 1 ? "B" : "C",
}));
return {
id: q.question_number,
text: q.text,
options: mappedOptions,
};
});
}, [cattellQuery.data]);
const glasserTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList =
glasserQuery.data?.questions && glasserQuery.data.questions.length > 0
? glasserQuery.data.questions
: glasserFallbackQuestions;
const defaultGlasserOptions = [
{ label: locale === "fa" ? "خیلی کم (۱)" : "Very Low (1)", value: 1 },
{ label: locale === "fa" ? "کم (۲)" : "Low (2)", value: 2 },
{ label: locale === "fa" ? "متوسط (۳)" : "Moderate (3)", value: 3 },
{ label: locale === "fa" ? "زیاد (۴)" : "High (4)", value: 4 },
{ label: locale === "fa" ? "خیلی زیاد (۵)" : "Very High (5)", value: 5 },
];
return questionsList.map((q) => ({
id: q.question_number,
text: q.text,
info:
"factor" in q
? (q.factor as string)
: "factor_code" in q
? (q.factor_code as string)
: undefined,
options: defaultGlasserOptions,
}));
}, [glasserQuery.data, locale]);
const visibleQuestions = useMemo(() => {
if (!item) {
return [];
}
const hasDobQuestion = item.questions.some(
(q) => q.title === "Date of Birth" || q.title === "تاریخ تولد",
);
return item.questions
.filter((question) => {
if (
hasDobQuestion &&
(question.title === "Age" || question.title === "سن")
) {
return false;
}
return isQuestionVisibleForProfile(question, profileContext);
})
.map((question) => ({
...question,
required: isQuestionRequiredForProfile(question, profileContext),
}));
}, [item, profileContext]);
const requiredQuestionsCount = useMemo(
() => visibleQuestions.filter((q) => q.required).length,
[visibleQuestions],
);
useEffect(() => {
if (isProfileLoading) {
return;
}
if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) {
return;
}
router.replace(questionsListHref);
}, [isProfileLoading, item, profileContext, questionsListHref, router]);
if (isProfileLoading && item) {
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col items-center justify-center bg-[#F7F1F0]">
<DotsLoader className="text-[#F2465F] scale-150" />
</main>
</>
);
} else if (
!item ||
!isQuestionListItemVisibleForProfile(item, profileContext)
) {
return null;
}
if (item && item.questions.length === 0) {
if (isTestStarted) {
const isQuestionsLoading = isCattellSlug
? cattellQuery.isLoading || cattellQuery.isFetching
: isGlasserSlug
? glasserQuery.isLoading || glasserQuery.isFetching
: false;
if (isQuestionsLoading) {
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col items-center justify-center bg-[#F7F1F0]">
<DotsLoader className="text-[#F2465F] scale-150" />
</main>
</>
);
}
const activeTestQuestions = isCattellSlug
? cattellTestQuestions
: isGlasserSlug
? glasserTestQuestions
: [];
if (activeTestQuestions.length === 0) {
const isError = isCattellSlug
? cattellQuery.isError
: isGlasserSlug
? glasserQuery.isError
: false;
const refetch = isCattellSlug
? cattellQuery.refetch
: glasserQuery.refetch;
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col items-center justify-center gap-4 bg-[#F7F1F0] px-6 text-center">
<p className="font-semibold text-[#1B1B1B]">
{isError
? locale === "fa"
? "خطا در دریافت سوالات از سرور. لطفاً از اتصال اینترنت یا ورود به حساب کاربری اطمینان حاصل کنید."
: "Failed to load questions from server. Please check your connection or login status."
: locale === "fa"
? "سوالاتی برای این آزمون یافت نشد."
: "No questions found for this test."}
</p>
<div className="flex gap-3">
<button
type="button"
onClick={() => setIsTestStarted(false)}
className="rounded-xl bg-[#EFEFEF] px-4 py-2 text-sm font-semibold text-[#1B1B1B]"
>
{closeLabel}
</button>
<button
type="button"
onClick={() => refetch()}
className="rounded-xl bg-[#F2465F] px-4 py-2 text-sm font-semibold text-white shadow-md"
>
{locale === "fa" ? "تلاش مجدد" : "Retry"}
</button>
</div>
</main>
</>
);
}
const handleTestFinish = async (
answers: Record<number, string | number>,
) => {
if (isCattellSlug) {
const responses = Object.entries(answers).map(([qNum, option]) => ({
question_number: Number(qNum),
option: String(option),
}));
try {
await submitCattellMutation.mutateAsync({ responses });
} catch {
// Ignore if already submitted or API returned error
}
try {
window.localStorage.setItem(
getQuestionStorageKey(item.slug),
JSON.stringify({ completed: true }),
);
} catch {}
} else if (isGlasserSlug) {
const responses = Object.entries(answers).map(([qNum, score]) => ({
question_number: Number(qNum),
score: Number(score),
}));
try {
await submitGlasserMutation.mutateAsync({ responses });
} catch {
// Ignore if already submitted
}
try {
window.localStorage.setItem(
getQuestionStorageKey(item.slug),
JSON.stringify({ completed: true }),
);
} catch {}
}
await new Promise((resolve) => setTimeout(resolve, 1200));
};
return (
<TestQuestionsFlow
title={item.title}
questions={activeTestQuestions}
closeLabel={closeLabel}
informationLabel={informationLabel}
onClose={() => setIsTestStarted(false)}
onFinish={handleTestFinish}
/>
);
}
const bulletKey =
item.slug === "glasser_5_needs_test" ? "glasser" : "personality";
const bullets = t.questions.testIntroBullets[bulletKey];
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
/>
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
{item.title}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
helpTitle={item.title}
helpDescription={description}
/>
</div>
</StickyHeader>
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-3 min-h-0">
<TestIntroPage
title={item.title}
estimateTime={item.estimate}
description={t.questions.testIntroEstimateLabel}
bulletPoints={bullets}
disclaimerText={t.questions.testIntroDisclaimer}
startLabel={t.questions.testIntroStart}
onStart={() => {
setIsTestStarted(true);
}}
/>
</div>
</main>
</>
);
}
const dobQuestion = visibleQuestions.find(
(question) => question.title === "Date of Birth",
);
const dobQuestionIndex = visibleQuestions.findIndex(
(question) => question.title === "Date of Birth",
);
return (
<>
<PageBackground disabled />
<AnswerPaceSheet
slug={item.slug}
title={title}
description={description}
continueLabel={continueLabel}
/>
<QuestionAnswersProvider slug={item.slug} questions={visibleQuestions}>
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<div className="flex items-center gap-4">
<QuestionExitNavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
exitHref={questionsListHref}
/>
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
{item.title}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
helpTitle={item.title}
helpDescription={description}
/>
</div>
</StickyHeader>
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-3 min-h-0">
<QuestionFlowWrapper
visibleQuestions={visibleQuestions}
itemSlug={item.slug}
dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
requiredQuestionsCount={requiredQuestionsCount}
continueLabel={continueLabel}
questionsListHref={questionsListHref}
/>
</div>
</main>
</QuestionAnswersProvider>
</>
);
}